--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ecd47eee1abb91524ddaeb2deac67aa2aac578d0
Parents : 56eaeaf
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T09:22:39-05:00
feat(Android): implement stale storage lock clearance in meshchat_wrapper
Changes
5 files changed, 232 insertions(+), 4 deletions(-)
Diff
diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py
index 937b0510..268a0b7d 100644
--- a/android/app/src/main/python/meshchat_wrapper.py
+++ b/android/app/src/main/python/meshchat_wrapper.py
@@ -71,6 +71,24 @@ def _patch_aiohttp_run_app_for_android():
return web, original_run_app
+def _clear_stale_storage_lock(storage_dir):
+ """Drop a leftover soft lock from a previous process death.
+
+ Android often lacks flock (ENOSYS), so StorageLock falls back to a PID
+ file. After force-stop / crash the old PID may still look "alive" under
+ Android's process model, which would make boot exit with SystemExit(1).
+ The Java wrapper already serializes start_server, so clearing is safe.
+ """
+ if not storage_dir:
+ return
+ lock_path = os.path.join(storage_dir, ".meshchatx.lock")
+ try:
+ if os.path.exists(lock_path):
+ os.remove(lock_path)
+ except OSError as exc:
+ print(f"meshchat_wrapper: could not clear storage lock: {exc}")
+
+
def start_server(port=8000, app_files_dir=None):
global _server_loop_active
with _server_loop_lock:
@@ -88,6 +106,7 @@ def start_server(port=8000, app_files_dir=None):
os.makedirs(storage_dir, exist_ok=True)
os.makedirs(reticulum_config_dir, exist_ok=True)
_ensure_android_reticulum_config(reticulum_config_dir)
+ _clear_stale_storage_lock(storage_dir)
original_signal = signal.signal
@@ -140,6 +159,13 @@ def start_server(port=8000, app_files_dir=None):
if aiohttp_run_app_patch is not None:
web_module, original_run_app = aiohttp_run_app_patch
web_module.run_app = original_run_app
+ except SystemExit as e:
+ # Chaquopy surfaces SystemExit as PyException; re-raise as RuntimeError
+ # so Java retry/error UI gets a readable message.
+ code = getattr(e, "code", e)
+ message = f"MeshChatX exited during startup (code={code})"
+ print(f"Error starting MeshChatX server: {message}")
+ raise RuntimeError(message) from e
except Exception as e:
print(f"Error starting MeshChatX server: {e}")
import traceback
diff --git a/meshchatx/src/backend/storage_lock.py b/meshchatx/src/backend/storage_lock.py
index 70a891e0..cb7dfc1c 100644
--- a/meshchatx/src/backend/storage_lock.py
+++ b/meshchatx/src/backend/storage_lock.py
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
import atexit
+import errno
import os
import sys
@@ -9,11 +10,55 @@ class StorageLockError(OSError):
pass
+def _pid_alive(pid: int) -> bool:
+ if pid <= 0:
+ return False
+ if pid == os.getpid():
+ return True
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ return True
+ except OSError:
+ return False
+ return True
+
+
+def _soft_lock_contested(other_pid: int | None) -> bool:
+ """Whether a soft PID lock should block acquire.
+
+ On platforms without flock (notably Android), process liveness probes are
+ unreliable across app restarts, so only treat the *current* PID as held.
+ Desktop soft-lock still uses kill(pid, 0) when flock is unsupported.
+ """
+ if other_pid is None:
+ return False
+ if other_pid == os.getpid():
+ return True
+ if sys.platform == "android" or "ANDROID_ROOT" in os.environ:
+ return False
+ return _pid_alive(other_pid)
+
+
+def _flock_unsupported(exc: BaseException) -> bool:
+ if isinstance(exc, NotImplementedError):
+ return True
+ if not isinstance(exc, OSError):
+ return False
+ unsupported = {errno.ENOSYS, errno.EOPNOTSUPP}
+ if hasattr(errno, "ENOTSUP"):
+ unsupported.add(errno.ENOTSUP)
+ return exc.errno in unsupported
+
+
class StorageLock:
def __init__(self, storage_dir: str):
self.storage_dir = os.path.abspath(storage_dir)
self.lock_path = os.path.join(self.storage_dir, ".meshchatx.lock")
self._handle = None
+ self._soft = False
def acquire(self) -> None:
os.makedirs(self.storage_dir, exist_ok=True)
@@ -27,24 +72,60 @@ class StorageLock:
else:
import fcntl
- fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ try:
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except (OSError, NotImplementedError) as exc:
+ if not _flock_unsupported(exc):
+ raise
+ # Android and some filesystems do not implement flock.
+ self._acquire_soft()
+ self._soft = True
+ atexit.register(self.release)
+ return
+ except StorageLockError:
+ raise
except OSError as exc:
+ if self._handle is not None:
+ self._handle.close()
+ self._handle = None
+ raise StorageLockError(
+ f"Another MeshChatX instance is already using storage at {self.storage_dir}",
+ ) from exc
+ self._write_pid()
+ atexit.register(self.release)
+
+ def _acquire_soft(self) -> None:
+ assert self._handle is not None
+ self._handle.seek(0)
+ raw = self._handle.read().strip()
+ other_pid = None
+ if raw:
+ try:
+ other_pid = int(raw.decode("ascii", errors="ignore").strip())
+ except ValueError:
+ other_pid = None
+ if _soft_lock_contested(other_pid):
self._handle.close()
self._handle = None
raise StorageLockError(
f"Another MeshChatX instance is already using storage at {self.storage_dir}",
- ) from exc
+ )
+ self._write_pid()
+
+ def _write_pid(self) -> None:
+ assert self._handle is not None
self._handle.seek(0)
self._handle.truncate()
self._handle.write(str(os.getpid()).encode())
self._handle.flush()
- atexit.register(self.release)
def release(self) -> None:
if self._handle is None:
return
try:
- if sys.platform == "win32":
+ if self._soft:
+ pass
+ elif sys.platform == "win32":
import msvcrt
self._handle.seek(0)
@@ -56,7 +137,15 @@ class StorageLock:
except OSError:
pass
try:
+ if self._soft:
+ try:
+ self._handle.seek(0)
+ self._handle.truncate()
+ self._handle.flush()
+ except OSError:
+ pass
self._handle.close()
except OSError:
pass
self._handle = None
+ self._soft = False
diff --git a/tests/backend/test_database_lifecycle_safety.py b/tests/backend/test_database_lifecycle_safety.py
index d3ef5c03..7f9481b4 100644
--- a/tests/backend/test_database_lifecycle_safety.py
+++ b/tests/backend/test_database_lifecycle_safety.py
@@ -148,6 +148,81 @@ def test_storage_lock_rejects_second_instance(temp_dir):
lock_a.release()
+def test_storage_lock_soft_fallback_when_flock_unsupported(temp_dir, monkeypatch):
+ import errno
+ import fcntl
+
+ real_flock = fcntl.flock
+
+ def flock_enosys(fd, op):
+ if op & fcntl.LOCK_NB:
+ raise OSError(errno.ENOSYS, "Function not implemented")
+ return real_flock(fd, op)
+
+ monkeypatch.setattr(fcntl, "flock", flock_enosys)
+
+ lock_a = StorageLock(temp_dir)
+ lock_a.acquire()
+ assert lock_a._soft is True
+ # Same-process re-acquire must still fail under soft lock.
+ lock_b = StorageLock(temp_dir)
+ with pytest.raises(StorageLockError):
+ lock_b.acquire()
+ lock_a.release()
+
+ lock_c = StorageLock(temp_dir)
+ lock_c.acquire()
+ assert lock_c._soft is True
+ lock_c.release()
+
+
+def test_storage_lock_soft_allows_dead_pid(temp_dir, monkeypatch):
+ import errno
+ import fcntl
+
+ monkeypatch.setattr(
+ fcntl,
+ "flock",
+ lambda *_a, **_k: (_ for _ in ()).throw(
+ OSError(errno.ENOSYS, "Function not implemented")
+ ),
+ )
+ lock_path = os.path.join(temp_dir, ".meshchatx.lock")
+ with open(lock_path, "wb") as handle:
+ handle.write(b"999999999")
+
+ lock = StorageLock(temp_dir)
+ lock.acquire()
+ assert lock._soft is True
+ lock.release()
+
+
+def test_storage_lock_android_soft_ignores_foreign_pid(temp_dir, monkeypatch):
+ import errno
+ import fcntl
+
+ from meshchatx.src.backend import storage_lock as storage_lock_mod
+
+ monkeypatch.setattr(
+ fcntl,
+ "flock",
+ lambda *_a, **_k: (_ for _ in ()).throw(
+ OSError(errno.ENOSYS, "Function not implemented")
+ ),
+ )
+ monkeypatch.setenv("ANDROID_ROOT", "/system")
+ monkeypatch.setattr(storage_lock_mod.os, "kill", lambda *_a, **_k: None)
+
+ lock_path = os.path.join(temp_dir, ".meshchatx.lock")
+ with open(lock_path, "wb") as handle:
+ handle.write(b"1")
+
+ lock = StorageLock(temp_dir)
+ lock.acquire()
+ assert lock._soft is True
+ lock.release()
+
+
def test_restore_rejects_non_sqlite_backup(temp_dir):
db_path = os.path.join(temp_dir, "main.db")
db = Database(db_path)
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 4a26f392..2599f46a 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -163,6 +163,15 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
const initPy = readSource("vendor/lxmfy/lxmfy/__init__.py");
expect(initPy.length).toBeGreaterThan(0);
});
+
+ it("Android wrapper clears stale storage lock before main()", () => {
+ const wrapper = readSource("android/app/src/main/python/meshchat_wrapper.py");
+ expect(wrapper).toContain("_clear_stale_storage_lock");
+ expect(wrapper).toContain(".meshchatx.lock");
+ const lock = readSource("meshchatx/src/backend/storage_lock.py");
+ expect(lock).toContain("_flock_unsupported");
+ expect(lock).toContain("_acquire_soft");
+ });
});
describe("behavior contracts: Reticulum instance settings", () => {
diff --git a/tests/test_android_meshchat_wrapper.py b/tests/test_android_meshchat_wrapper.py
index ecc5f176..95117bc7 100644
--- a/tests/test_android_meshchat_wrapper.py
+++ b/tests/test_android_meshchat_wrapper.py
@@ -62,3 +62,32 @@ def test_start_server_hook_failure_still_invokes_main(monkeypatch):
importlib.reload(meshchat_wrapper)
meshchat_wrapper.start_server(8000, None)
assert calls == [1]
+
+
+def test_start_server_clears_stale_storage_lock(monkeypatch, tmp_path):
+ import meshchatx.meshchat as mm
+
+ calls: list[int] = []
+ monkeypatch.setattr(mm, "main", lambda: calls.append(1))
+
+ storage = tmp_path / "meshchatx" / "storage"
+ storage.mkdir(parents=True)
+ lock_path = storage / ".meshchatx.lock"
+ lock_path.write_bytes(b"12345")
+
+ import meshchat_wrapper
+
+ importlib.reload(meshchat_wrapper)
+ meshchat_wrapper.start_server(8000, str(tmp_path))
+ assert calls == [1]
+ assert not lock_path.exists()
+
+
+def test_clear_stale_storage_lock_missing_is_ok(tmp_path):
+ import meshchat_wrapper
+
+ importlib.reload(meshchat_wrapper)
+ meshchat_wrapper._clear_stale_storage_lock(str(tmp_path / "missing"))
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ meshchat_wrapper._clear_stale_storage_lock(str(storage))
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────